Титановый отвар
Пока страшен Haskell
9/12/2019

Пока я только начинаю разбираться с хаскеллем, многие его фишки выглядят страшно и непонятно. Всё-таки велика разница между ним и привычными императивными языками. Но я всё равно продолжаю: очень нравится лаконичность и ёмкость кода. А ещё он здорово заставляет думать иначе, другими категориями. Приходится мыслить декларативно, не сильно отвлекаясь на последовательность действий, которые приведут к результату.

Писать на нём пока сложно. Непривычен синтаксис, незнакомы библиотеки, непросто было даже подобрать себе удобную среду разработки. Очень много времени тратится в попытках постичь документацию к модулям, которые я использую в проекте. Почти нет примеров кода, в которых можно было бы подсмотреть, как пользоваться той или иной функцией, когда стандартного описания не хватает. Да что там -- документация к библиотекам, как правило, просто отвратительная. Но постепенно код становится всё более и более прозрачным и понятным; одно это уже приносит невероятное удовольствие. И потихоньку начинаешь понимать, почему некоторые называют его Python4.

Я попробую записывать мысли, здорово упростившие мне понимание концепций языка. Надеюсь удержать уровень повествования выше "собственного блокнота, который никому не надо показывать". Хочется не просто зафиксировать прорывные этапы понимания, но объяснить их своим языком так, чтобы, возможно, помочь кому-то ещё "схватить" язык.

Запись функций

Самое важное: символ = -- это именно математический знак равенства, а не присваивания, как во многих других языках. Мы как бы говорим, что левая сторона выражения эквивалентна правой. Не правда ли, сразу понятнее выглядит синтаксис pattern matching?

func []     = "nil"
func (x:xs) = x ++ "," ++ func xs

Мы даём определение двум выражениям:

  • функция func от пустого списка равна строке "nil";
  • func от непустого списка равна сумме головы списка, запятой и функции func от хвоста списка.

В эту же парадигму отлично ложится определение констант. Константа -- как бы функция "от ничего", которая при любом x будет равна какому-то одному значению y. Приведу пример из отличной книжки Learn you a Haskell for great good:

largestDivisible :: Integer
largestDivisible = head (filter p [100000,99999..])
where p x = x `mod` 3829 == 0

Что здесь происходит? Функция largestDivisible не принимает никаких значений. Определён только её выходной тип -- Integer. Если вычислить выражение, которому равна функция, получится 99554: самое большое число из диапазона от нуля до 100000, нацело делящееся на 3829. Вот этому значению и будет равно выражение largestDivisible в течение всего времени выполнения программы. Константа!

В целом синтаксис языка больше похож на запись формул, уравнений, а не на язык программирования. let ... in и where чего только стоят. Это же классическое

$E=mc^2$, где m -- масса тела, а c -- скорость света в вакууме.

Учитывая сказанное, гарды (guards) можно воспринимать как систему уравнений с ограничениями. Сравните:

isNegative x
| x < 0 = "Negative"
| x == 0 = "Zero"
| otherwise = "Positive"

$isNegative = \begin{cases} Negative, & \text{если x < 0} \\ Zero, & \text{если x = 0} \\ Positive, & \text{если x > 0} \end{cases}$

Очень похоже!

Такой декларативный подход позволяет хаскеллю быть языком с ленивой моделью вычислений. Программист описывает не последовательность действий, а связи между сущностями. Детализируя их и их взаимодействия до нужной степени. А компилятор уже сам разбирается, когда что выполнять (и выполнять ли вообще). Собственно, работа компилятора заключается в том, чтобы максимально упростить вычисляемое выражение (программу). Если дальше упрощать некуда -- всё, программа завершена, результатом её работы является это самое максимально упрощённое выражение. Очень красиво с математической точки зрения.

Упрощение выражений (η-преобразование)

Поначалу код на хаскелле выглядит странно. Из кода функций совершенно непонятно, что они делают, даже если в коде нет "страшных значков" типа <$> или <*>. Вот вроде бы всё просто здесь, а всё равно неясно, куда девается String, объявленный в декларации функции:

getBetweenColons :: String -> String
getBetweenColons =
let beforeColon = takeWhile (/= ':')
afterColon = dropWhile (/= ':')
in init . beforeColon . tail . afterColon

Оказывается, всё просто. Считайте, что это приведение подобных членов, школьная алгебра. Если в левой части выражения стоит одинокий x, и в правой части тоже стоит одинокий x, их обоих можно выкинуть. Следующие две функции равноценны:

getLinesWith :: String -> String -> [String]
getLinesWith t c = filter (isSpecialLine t) . lines $ c

getLinesWith :: String -> String -> [String]
getLinesWith t = filter (isSpecialLine t) . lines

Значение c мы можем выкинуть, т.к. внутри getLinesWith оно "съедается" в первую очередь -- функцией lines. А вот t выкинуть нельзя, потому что тогда компилятору будет непонятно, какое из двух значений куда скармливать.

Теперь понятно, что в первом примере аргумент функции приходит сначала в afterColon, потом к получившемуся применяют tail, ну и так далее по цепочке, пока результат функции init не будет отдан наружу. А писать аргумент (ещё и именовать его! пфф) до знака равенства и после afterColon просто необязательно: компилятор сам догадается, что тут к чему. Удобно.

За этой механикой стоит идея каррирования (частичного применения) функций. Ну и композиция оных, конечно: то самое (f . g) x = f (g x). На самом деле, убирая аргумент из тела функции, мы создаём частично применённую функцию, которая ждёт свой единственный аргумент. Запись z x = (f . g) x равноценна записи z = f . g. Потому что в коде мы как раз применим z к недостающему аргументу, вызвав её как z x.

Кстати, если пользоваться линтером (например, HLint), он будет сразу советовать такое упрощение (Eta-reduce).

Монады

Вот написал уже немного своих аналогий и пониманий, но прочитал последний пункт этого списка и осёкся. Правильно: единственный способ врубиться в эти штуки --

  • Don't read the monad tutorials.
  • No really, don't read the monad tutorials.
  • Learn about Haskell types.
  • Learn what a typeclass is.
  • Read the Typeclassopedia.
  • Read the monad definitions.
  • Use monads in real code.
  • Don't write monad-analogy tutorials.